Docker 基础(二)- Dockerfile

理论知识

关于 Dockerfile 的详细使用,请参考 Dockerfile reference 说明。

Dockerfile 的本质

Dockerfile 是一个文本格式的镜像构建蓝图。它由一系列命令和参数组成,每条指令构建一层,最终叠加形成一个只读的 Docker 镜像。它的核心底层是联合文件系统(UnionFS)与镜像层(Layers)。

  • Dockerfile 中的很多指令(如 RUN、COPY、ADD)都会创建一个新的镜像层(Layer)。
  • 镜像层是只读的、可复用的。
  • 当容器启动时,Docker 会在这些只读层的顶端添加一个可读写层(Container Layer)。
  • 分层机制的好处:
    • 极其节省空间:如果多个镜像基础层相同,它们在宿主机上只会存储一份。
    • 极大提升构建速度:未改变的层会直接触发构建缓存(Build Cache),无需重新下载或计算。


常用的关键字

  • FROM:指定新镜像的基础镜像。Dockerfile 必须以 FROM 开头(除非前面有 ARG 变量)。在多阶段构建中,一个 Dockerfile 可以出现多个 FROM,用以分离编译环境和运行环境。
  • MAINTAINER:声明镜像的作者/维护者信息。这个关键字在旧版本很常用,但现在已经被官方废弃了!现在推荐使用通用的 LABEL 指令来代替它,例如:LABEL maintainer=”owlias “。
  • RUN:在 docker build(镜像构建)阶段执行的命令。通常用于执行 apt-get install、mvn clean package 等去搭建基础环境。每一个 RUN 指令都会在只读层上叠加新的一层(Layer)。应该尽量使用 && 将多条 Shell 命令合并成一个 RUN,并在命令末尾顺手清理掉安装包缓存(如 rm -rf /var/cache/apk/*),以追求极致的镜像瘦身。
  • EXPOSE:声明容器运行时打算监听的端口。它仅仅是一个声明(留给运维或微服务编排工具K8s看的),并不会自动在宿主机上做端口映射。如果想真正映射端口,启动容器时还是要老老实实用 -p
  • WORKDIR:为后续的 RUN、CMD、ENTRYPOINT、COPY、ADD 指令设置工作目录(落脚点)。
  • USER:指定运行后续命令以及容器启动时的用户名或 UID。默认情况下容器是用 root 用户运行的,这存在极大的容器逃逸安全隐患。标准的安全做法是在构建后期先通过 RUN useradd 创建一个低权限的普通用户,然后通过 USER myuser 切换,确保容器进程以非 root 身份在生产环境运行。
  • ENV:设置环境变量。这些变量在镜像构建过程中以及容器运行时都持久有效。后续指令可以直接通过 “$变量名” 来引用它。
  • ADD:COPY 的增强版,具备两个特殊超能力,即如果源文件是宿主机的本地压缩包(如 .tar.gz),复制到容器内时会自动帮你解压成目录;另一方面它也支持远程 URL。
  • COPY:纯粹地、规规矩矩地复制本地文件或目录。生产环境无脑推荐使用,含义最单一、最安全。
  • VOLUME:在容器内创建一个匿名数据卷挂载点。它的核心目的是防止用户忘记挂载目录,导致持久化数据(如 MySQL 数据、Redis AOF 文件)随容器销毁而丢失。
    • 在 Dockerfile 中声明 VOLUME [“/data”] 后,即使启动容器时没加 -v 参数,Docker 也会自动在宿主机的 /var/lib/docker/volumes/ 下生成一个随机名字的文件夹与之绑定,强行保障数据安全。
    • 如果 Dockerfile 指定了 VOLUME,而 docker run 时又指定了 -v(具名挂载),两者的路径如果发生冲突,docker run -v 会以绝对优势强行覆盖 Dockerfile 中的声明。
      • 例如当你执行 docker run -v /host/my_data:/data,结果是 Dockerfile 里的匿名卷声明直接失效,Docker 会直接把宿主机的 /host/my_data 目录绑定到容器的 /data,此时绝对不会在宿主机的 /var/lib/docker/volumes/ 下生成那个乱码一样的匿名卷文件夹。
      • 又比如你执行 docker run -v my_named_vol:/data,结果是 Dockerfile 里的匿名卷声明同样直接失效,Docker 会在宿主机创建一个名字叫 my_named_vol 的干净卷,并把它挂载到容器的 /data,而不会产生任何匿名垃圾。
      • 如果你在 Dockerfile 里写了 VOLUME [“/data”],但你启动时因为疏忽,敲成了:docker run -v /host/my_data:/app_data my-image(路径写错了,把 /data 敲成了 /app_data)。Docker 依然会在你的宿主机底层偷偷创建一个无名的匿名卷挂载到 /data。随着你容器不停地重启、销毁、重建,宿主机的 /var/lib/docker/volumes/ 下就会积压大量几百兆甚至几个G的无名孤儿文件夹,直到某天把你的服务器磁盘彻底撑爆。这时候生产环境定期执行 docker volume prune,这个命令会一枪放倒所有由于这种“擦肩而过”产生的、目前没有被任何容器使用的匿名垃圾卷。
  • CMD:提供容器启动的默认命令及参数。它的特点是极易被覆盖。如果在 docker run 后面随便加了任何参数(如 docker run my-image /bin/bash),Dockerfile 里的 CMD 会被直接无情覆盖,完全不执行。
  • ENTRYPOINT:让容器像一个普通的二进制可执行文件一样运行,其参数很难被覆盖。它的特点是 docker run 后面附带的任何额外参数,都会被当成附加参数追加(Append)到 ENTRYPOINT 命令的尾部。


RUN、CMD、ENTRYPOINT

  • RUN:在镜像构建阶段(docker build)执行的命令。通常用于安装软件、配置环境、创建目录。它会生成新的镜像层。

  • CMD:在容器启动阶段(docker run)执行的默认命令。Dockerfile 中只能有一条 CMD,如果写了多条,只有最后一条生效。在实际的使用中,CMD 极易被 docker run 后面附带的参数直接覆盖。

  • ENTRYPOINT:也是在容器启动时执行的命令,但它不会被轻易覆盖。在实际生产中,通常用 ENTRYPOINT 指定容器的启动主体程序,用 CMD 充当其默认参数。例如:

    1
    2
    ENTRYPOINT ["redis-server"]
    CMD ["/etc/redis/redis.conf"]

    当用户执行 docker run my-redis –port 6380 时,–port 6380 会覆盖 CMD 传递给 ENTRYPOINT,变成 redis-server –port 6380 启动,极其优雅。


COPY、ADD

  • COPY:纯粹地将宿主机的本地文件/目录复制到镜像内。功能单一,但高效且推荐。
  • ADD:COPY 的增强版。
    • 它支持自动解压:如果源文件是个本地压缩包(.tar.gz),它会自动帮你在容器内解压。
    • 它支持远程 URL:可以直接去下载网络文件(但不推荐,因为会产生多余的下载缓存层)。
    • 生产环境无脑推荐 COPY,除非你明确需要本地压缩包自动解压的功能。


编写高水准的 Dockerfile

  • 第一,采用多阶段构建(Multi-Stage Builds)—— 核心减重神技

    • 以 Java 为例,编译源码需要 Maven/JDK,但运行只需要 JRE。如果把 Maven 和源码都留在镜像里,镜像动辄 1GB。

    • 实际可以在同一个 Dockerfile 中定义多个 FROM,前一个阶段负责编译,后一个阶段只把编译好的产物(如 .jar)通过 COPY –from 复制过来。

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      # 第一阶段:编译构建
      FROM maven:3.9-eclipse-temurin-17 AS builder
      WORKDIR /build
      COPY pom.xml .
      COPY src ./src
      RUN mvn clean package -DskipTests

      # 第二阶段:最小化运行
      FROM eclipse-temurin:17-jre-alpine
      WORKDIR /app
      # 隔空取物:只把第一阶段的 jar 包偷过来,抛弃所有编译依赖
      COPY --from=builder /build/target/mall-service.jar ./app.jar

      EXPOSE 8080
      ENTRYPOINT ["java", "-jar", "app.jar"]
  • 第二,精明利用构建缓存(Cache)

    • Docker 的缓存机制是:一旦某一层由于代码改变导致缓存失效,它后面的所有层缓存将全部报废。
    • 坏榜样:先 COPY . .(复制整份源码),再 RUN mvn install。这样只要你改了一行 Java 代码,Maven 依赖层就会被迫全部重新下载,慢到崩溃。
    • 好榜样:先仅仅 “COPY pom.xml .”,然后 RUN mvn dependency:go-offline(先下载好依赖并形成固定的镜像层),最后再 “COPY src ./src” 编译。这样只要 pom.xml 没变,依赖层永远走缓存,构建速度从 10 分钟飙升到 10 秒。
  • 第三,合并 RUN 指令,清理多余残骸

    • 每一个 RUN 都会增加一层。应该用 && 将多条 Shell 命令合并成一条。
    • 并在同一层命令的末尾,顺手清理缓存和临时文件(如 rm -rf /var/cache/apk/* 或 apt-get clean),否则这些垃圾数据会被永久固化在只读层里,即便后续用新指令删除也无法缩减镜像体积。
  • 第四,选用精简的基础镜像(Base Image)

    • 拒绝盲目使用 ubuntu 或 centos 作为底包。
    • 优先选用 Alpine(一个只有 5MB 左右的极其精简的安全 Linux 发行版)或者 Slim 版本(瘦身版)。
  • 第五,拒绝使用 Root 用户运行

    • 默认情况下,容器内部的进程是用 root 运行的。一旦容器被黑客攻破且发生逃逸,宿主机将直接沦陷。应在 Dockerfile 末尾使用 RUN useradd -m myuser && USER myuser 切换为低权限普通用户。
  • 第六,显式声明端口和环境变量

    • 用 EXPOSE 和 ENV 明确暴露微服务的边界,方便业务网关和微服务编排软件进行健康检查与服务发现。


简单案例

centos7+vim+ifconfig+jdk8

第一步:准备 Dockerfile

vim ./Dockerfile

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
# 1. 指定基础镜像:经典的 CentOS 7
FROM centos:7

# 填写维护者信息(规范化)
LABEL maintainer="owlias <owlias@koohub.com>"

# 2. 核心避坑:修复 CentOS 7 官方 YUM 源失效问题,将废弃的官方源指向阿里云的 centos-vault 历史归档源
RUN sed -i 's/mirrorlist/#mirrorlist/g' /etc/yum.repos.d/CentOS-*.repo && sed -i 's|#baseurl=http://mirror.centos.org/centos/\$releasever|baseurl=http://mirrors.aliyun.com/centos-vault/7.9.2009|g' /etc/yum.repos.d/CentOS-*.repo && yum clean all && yum makecache

# 3. 安装常用工具:vim 和 ifconfig(net-tools)。-y 代表自动确认,安装完后用 yum clean all 清理缓存,缩减镜像体积
RUN yum install -y vim net-tools && yum clean all

# 4. yum 安装配置 OpenJDK 8
#RUN yum install -y java-1.8.0-openjdk-devel && yum clean all
#ENV JAVA_HOME=/usr/lib/jvm/java-1.8.0-openjdk
#ENV PATH=$JAVA_HOME/bin:$PATH
#ENV CLASSPATH=.:$JAVA_HOME/lib/dt.jar:$JAVA_HOME/lib/tools.jar

# 4. 本地安装配置 OpenJDK 8。事先在宿主机当前文件夹下准备好 jdk-8u491-linux-x64.tar.gz
ADD jdk-8u491-linux-x64.tar.gz /usr/local/java
ENV JAVA_HOME=/usr/local/java/jdk1.8.0_491
ENV JER_HOME=$JAVA_HOME/jre
ENV CLASSPATH=.:$JAVA_HOME/lib/dt.jar:$JAVA_HOME/lib/tools.jar:$JER_HOME/lib:$CLASSPATH
ENV PATH=$JAVA_HOME/bin:$PATH

# 5. 业务运行环境初始化,设置系统默认语言为 UTF-8,防止中文乱码
ENV LANG=en_US.UTF-8

# 指定容器启动后的默认工作目录
WORKDIR /root

# 容器启动时默认打开 bash 终端
CMD ["/bin/bash"]

第二步:构建镜像

在终端中切换到 Dockerfile 所在的目录下,执行以下构建命令(注意末尾有一个 . 代表当前路径):

1
2
$ docker build -t my-centos7-java8-dev:v1.0 .
$ docker images

第三步:启动容器

镜像构建成功后,我们直接启动一个容器,并切入交互式终端(-it)来检验我们的定制成果:

1
2
$ docker run -it --name mycentos7-java8-dev my-centos7-java8-dev:v1.0
$ docker ps

连入容器后,你会发现你直接站在了 /root(WORKDIR 指定的家目录)下。接下来依次敲入以下命令进行验证:

1
2
3
4
5
6
7
8
9
10
11
# 验证 ifconfig 网络工具
$$ ifconfig

# 验证 vim 编辑器
$$ vim test.txt

# 验证 JDK 8 环境
$$ java -version

# 验证环境变量
$$ echo $JAVA_HOME


发布微服务到docker容器

1
2
3
$ docker images
my-centos7-java17-dev:v1.0 921d4d9ada83 1.59GB 442MB
my-centos7-java8-dev:v1.0 e714513991ab 1.63GB 413MB

Dockerfile:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
FROM my-centos7-java17-dev:v1.0
LABEL maintainer="owlias <owlias@koohub.com>"

# 挂载临时目录,保障 Tomcat 极致性能
VOLUME /tmp

# 创建并切换到专属的工作目录
WORKDIR /app

# 直接用 COPY 把 jar 包拷贝并重命名,防止版本号变动导致后续命令跟着改,/app/demo-1.0.jar
COPY demo-springboot-1.0-SNAPSHOT.jar demo-1.0.jar
# 声明端口
EXPOSE 8081

# 优雅停机、PID=1 的启动正统指令
## 如果 PID=1,当你在微服务架构(如 K8s 或 Docker Compose)中执行 docker stop 时,系统发送的 SIGTERM(优雅关闭信号)能直达你的 Spring Boot 进程。
## Java 就能从容地断开数据库连接、从注册中心下线、处理完手头最后的 HTTP 请求再体面退出。如果是用别的方法写,信号会被外层的 Shell 拦截,导致容器到时间被强行杀掉(SIGKILL),极易造成线上数据丢失。
## 这里也可以清晰指定绝对路径 /app/demo-1.0.jar
ENTRYPOINT ["java", "-jar", "demo-1.0.jar"]

# 健康检查,|| 表示前面的命令执行失败就执行后面的命令
RUN yum update -y && yum install curl -y
HEALTHCHECK --interval=5s --timeout=3s cmd curl -fs http://localhost:8081/actuator/health || exit 1

构建镜像:

1
2
$ docker build -t my-centos7-java17-app:v1.0 .
$ docker images

启动容器:

1
2
3
# 启动和查看
$ docker run -d -p 8081:8081 --name mycentos7-java17-app --restart=unless-stopped my-centos7-java17-app:v1.0
$ docker ps

关于日志的查看:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
# 实时跟踪打印这个容器的 Spring Boot 日志(类似 tail -f)
## 日志存放的具体路径:
## 由于你在 Dockerfile 中使用的是 ENTRYPOINT ["java", "-jar", "demo-1.0.jar"],
## Spring Boot 打印在屏幕上的所有日志(即所谓的标准输出 stdout 和标准错误 stderr),
## 都会被 Docker 守护进程拦截并统一固化在宿主机的硬盘上。这些日志在宿主机的具具体存放路径为:
## /var/lib/docker/containers/[容器完整ID]/[容器完整ID]-json.log
$ docker logs -f mycentos7-java17-app

# 由于容器 ID 是一长串哈希乱码,手动去找很麻烦。你可以在宿主机终端执行以下命令,直接秒出该日志文件绝对路径:
$ docker inspect --format='{{.LogPath}}' mycentos7-java17-app
/var/lib/docker/containers/280f37bb752703824bf73a479fe56dff9fc1c8b859ac90b8d338ddf5f94c761e/280f37bb752703824bf73a479fe56dff9fc1c8b859ac90b8d338ddf5f94c761e-json.log

# 如果你不用命令限制它,这个 json.log 文件会随着业务运行无限变大,直到把宿主机磁盘撑爆。
# 在实际生产中,我们通常会在 docker run 时加上 --log-opt max-size=10m --log-opt max-file=3 来限制它的大小和文件数。

# 如果你在 Spring Boot 的 application.yml 或 logback-spring.xml 中配置了类似下面的日志输出路径:
# 那么这些日志文件会被写在容器内部的 /app/logs/ 目录下。
# 如果日志留在容器内部,一旦你执行了 docker rm mycentos7-java17-app,这些珍贵的线上业务日志会随着容器的销毁而瞬间蒸发,运维人员根本无法进行故障追溯。
logging:
file:
path: /app/logs # 或者配置了 name: /app/logs/error.log

# 为了让日志持久化,并且方便在宿主机上直接利用 tail -f 统一查看,大厂的标准做法是在启动容器时,利用 -v 参数把容器内的日志目录强行映射到宿主机上。你可以将启动命令改良为:
$ docker run -d \
-p 8081:8081 \
--name mycentos7-java17-app \
--priviledge \
-v /opt/apps/demo-app/logs:/app/logs \
--restart=unless-stopped \
my-centos7-java17-app:v1.0